Repository navigation
v0.12.0 — Slack, one agent session per thread (+ @-mention switch) - #211
Conversation
…d Lark (#19) Slack was configurable only by the owner (the global bot under Settings → Access); a regular user's "My IM bot" section offered Telegram and Lark but no Slack, so the platforms were not symmetric. A tenant now has an optional `TenantSlack` beside `TenantTelegram` / `TenantLark`, and everything the other two get, Slack gets: - the daemon runs a `slack@<tenant>` channel per configured tenant — fail-closed until a member is allowed, its rejected senders in the shared probe file, and Slack's thread-per-session and button contracts under that name; - `PUT /me/im` takes a `slack` block (both tokens checked with Slack before anything is written; a token change keeps the bound members), plus `GET /me/im/slack/user-id-candidates` (scoped to the caller's own bot) and `PUT /me/im/slack/allowed-users`; - `chat_send_file` resolves a tenant's Slack as a delivery target; - "My IM bot" gets a Slack card with the owner card's three steps. The create-app manifest route is open to every signed-in identity — it holds no secret — while the global bot's capture/allowlist stay owner-only. Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
… session (#19) Owner feedback: a channel showed nothing but command echoes — every `/ccteam new …` posted an anchor and the session lived in its thread, so the session's real first message became a reply and the channel never said what any session was about. In a channel that threads sessions, a command typed at the CHANNEL level no longer opens a session in its own place. `/new …` and `/use <sid>` preset the conversation's NEXT session (validated at once; the preset is a visible channel message, so everyone sees which model each session was set up with), and the next top-level message starts — or brings over — that session in its own thread, so the thread opens with that message. `/cd` there switches the project without adopting a session. The channel menu (`/ccteam` alone) is buttons first: 📁 projects · 🧠 model · 🧵 sessions · 📊 status; 🧠 walks harness → model (the advisory model catalog) and presets with a tap. Taps on a channel-level message act at the channel level too, so 🧵 sessions presets continuing the picked one. The Slack provider stops posting an anchor for a slash command, sends a top-level `!command` and a tap on a top-level message at the channel level, and keeps one safety net: a reply the bot cannot post (not in the channel) is explained through the command's ephemeral response. The daemon reports `session_threads` to the gateway with the button capability. Telegram, Lark and web are unchanged. Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
…ly when @-mentioned (#20) Telegram, Lark and Slack each got an inline (or no) group @-rule, none configurable, so two ccteam bots in one Slack channel both answered every message from a member allowed on both. Fix it once, below the providers: - Every IM credentials block (and each tenant's own bot) carries `require_mention` (default false = answer everything). - Providers only report a fact, `ChannelMessage.ambient` (a group/channel message that does not address the bot: Telegram @bot / /cmd@bot / reply, Lark mentions, Slack <@bot>); `MentionPolicy` is the single rule that decides what an ambient message does. - Provider-side early drop before any attachment download for ambient messages outside a thread; the daemon's inbound consumer is the final call, admitting an ambient Slack thread reply only when the gateway holds a session in that thread — or an addressed message already claimed it (its session may still be spawning off the loop). Decided before the security layer so chatter never spends the sender's rate-limit budget. - Telegram strips the bot's own handle from group text so `@bot /new codex` is still a command; Lark's hard-coded "group needs an @" becomes this switch (default now matches the other IMs). - REST: `require_mention` in the masked status; new `GET /me/im`; one parametrised `PUT /config/im/{platform}/require-mention` and the tenant twin. Re-saving tokens (web, tenant, `ccteam config`) keeps the switch. - SPA: one switch row on all six cards (owner + "my IM bot" x 3). Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
… cut (#20) Checker review of ac14ebb flagged a possible underflow in the Telegram handle-cut arithmetic. It is unreachable (a cut is only computed when the span ends with this bot's own @username, so it is at least that long), so no code change — a regression test now pins it, and the claimed-threads comment in the inbound consumer says what a full reset can actually lose. Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
Owner feedback: picking a model from the channel menu presets the next session at once, so there was no way to choose its reasoning effort from the menu (only by typing `/new <vendor> model= effort=`). After a model, the menu now offers that model's effort levels — its own declared set when it has one, else the vendor's ladder, the same rule the web composer uses (`effortRowsFor`) — with "default effort" first, which wires nothing. A harness with no effort axis (opencode) is not asked, so no menu that does nothing. Each tap lands in the same preset a typed `/new` makes; `new-effort:<effort>:<vendor>[:<model>]` leads with the effort because a model id may contain a `:` and an effort never does. Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
The tenant Telegram / Lark / Slack cards only tracked "done" in local component state and used the `/me/im` status for the @-mention switch alone, so a reload rendered a configured bot as unconfigured. Worse, the allowlist editors started empty while the allowlist PUTs replace the whole list, so binding one more id after a reload dropped the ones already bound. `/me/im` (and `/config/im`) now also return the bound Telegram chat ids and Lark open_ids (not secrets, as Slack member ids already were); the cards take every step's state, token fingerprints and allowlists from the server status. Tokens are still never echoed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`make install` built the shipped `release` profile (fat LTO, one codegen unit), whose final single-threaded LTO step dominated a local rebuild. The new `local-release` profile inherits release with thin LTO and 16 codegen units: a code-only rebuild drops from ~6m to ~35s on the dev box. `release` stays the published artifact's profile; install.sh treats the new build dir as a build tree, not an install location. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tree cb1c3a1 taught install.sh that `target/local-release` (the thin-LTO profile `make install` now builds) is a build tree, not an install location, but its three copies did not follow: `ccteam update`'s `resolve_install_dir_with`, the install-channel classifier behind `ccteam status` / `doctor`, and the DSH plugin's engine installer. So `ccteam update` run from a local-release build would install back into the build tree, and status would call that binary "other" with no update hint. Both drift guards (Rust and plugin) caught it. The profile list is now one constant in ccteam-core (`install_channel::CARGO_BUILD_PROFILES`) used by both Rust readers; the plugin mirrors it and install.sh keeps the shell copy, each still held in step by its drift test. Embedded plugin tarball re-packed (two-path reproducible 4eadf708…). Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
- no_silent_todo: Slack over Socket Mode (#19) delivered the last three F168 `TODO(V0.7-…)` anchors, so the "exactly three survive" check failed on a clean tree. All six are closed; assert none remain. - dsh_acp: `dsh_bin()` falls back to a copy the user's own `npx dsh` cached under `~/.npm/_npx`, so the "absent override → `dsh`" test failed on any host that has one. Pin HOME to an empty dir. Both live in `tests/*.rs`, which `make test-baseline` (= CI) skips. Co-Authored-By: firstintent <239836187+firstintent@users.noreply.github.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e97ff5eaa1
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| let held = match &thread_key { | ||
| Some(key) if addressed_threads.contains(key) => true, | ||
| Some((_, _, t)) => gateway.lock().await.has_current_session( |
There was a problem hiding this comment.
Expire addressed-thread claims after the spawn window
When require_mention is enabled, every addressed Slack message inserts its thread into addressed_threads, but entries are never removed except when the entire 4096-entry set is cleared. Consequently, after an addressed /stop removes the thread's current-session route—or after an addressed command such as /help creates no session—later ambient replies are still treated as held and can spawn a new session without mentioning the bot. Limit this claim to the in-flight spawn window or remove it once routing finishes.
Useful? React with 👍 / 👎.
| let ambient = group && !bot.is_some_and(|b| addresses_bot(&m, b)); | ||
| if self.mention_policy.drops_early(ambient, false) { | ||
| tracing::debug!(chat_id = %chat_id, "telegram: ambient group message dropped (require_mention)"); | ||
| continue; |
There was a problem hiding this comment.
Let bare Telegram commands bypass mention gating
With require_mention enabled in a Telegram group, a normal bare command such as /status is classified as ambient because addresses_bot recognizes a bot_command only when it has an @bot suffix. The new early-drop therefore discards these slash commands, even though commands are intended to remain available regardless of the mention switch. Treat Telegram bot_command entities as addressed, or exempt commands before applying this gate.
Useful? React with 👍 / 👎.
…the same sid
After `ccteam daemon restart`, a claude stream-json body that still has a
background task running (a run_in_background dev server, a watcher) does
not exit on stdin EOF: real claude 2.1.296 stays alive until that task
ends and then self-continues. The new daemon registers it DETACHED and
waits for it, which is unbounded, so every message to the sid queues
behind it forever. The receipt pointed at `/stop`, which ends the session:
it sets stopped_at, drops the chat's focus routes (the next message mints
a new sid) and strands the queued turns. The session looked permanently
lost.
`/interrupt` (IM and `POST /api/v1/sessions/{sid}/interrupt`) on a
detached sid now ends only that process. The DETACHED entry, the queue
and the focus routes stay, and the body watcher takes the same path it
takes for a body that exits on its own: it recovers what the body said,
rebuilds the sid with --resume (same context) and drains the queued
turns. The receipt names this way out, and the usage docs describe it.
Refs docs-local issue #211.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…en back" from
When `<uuid>.jsonl` exists, start_thread resumed and, on any failure, fell
back to a fresh `--session-id <uuid>` on the SAME uuid. Real claude refuses
that ("Session ID … is already in use", exit 1), so the fallback could never
start. It turned one failed resume into a second failure, and it logged a
chat_session_reset claiming the context was lost when the transcript was
intact.
The local arm now returns the resume failure itself, as a readable,
retryable error. Nothing sticks: the transcript is untouched, the killed
handshake child's body record is cleared (so the next spawn does not
post-mortem a turn that never ran), and the next message tries --resume
again. The remote fallback stays: there the transcript's existence is only
a guess.
Refs docs-local issue #211.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A detached body was waited for until it exited, and real claude stays alive after stdin EOF for as long as a background task it started is running. A session nobody steered by hand (a delegated child, a scheduled run, an unwatched chat) stayed stuck behind it, with every turn queued and none delivered. The body watcher now ends the wait on its own, but only while a turn is queued for that sid. A body nobody is waiting on is left alone, so a job it scheduled for later still runs. The adapter reads the vendor record (`detached_activity`; for claude, the tail of the transcript plus the subagent transcripts beside it): - last turn ended and quiet for 30s: the process is kept alive only by background work and can never take the turn; - a turn still open but silent for 30 min: treated the same way (no record: 30 min since the body was detached). A body that is mid-turn and still writing keeps the wait. The process is ended with one SIGKILL to the recorded pid (`session_body::kill_body`), and the same path is used for `/interrupt`. SIGTERM is not used: claude answers it by killing every background task it started (measured on 2.1.296), and those tasks are the user's dev servers, stacks and scheduled jobs. With SIGKILL they keep running, reparented to init, and ccteam signals nothing else. Only the process ends, never the session: sid, ledger and focus routes stay, and the watcher's natural-exit path resumes the same sid with --resume and delivers the queued turns. The ledger records the reason as `ended_idle` or `ended_stalled`. Refs docs-local issue #211. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A. Bodies are spawned as their own process-group leaders. Ending one (stop, /interrupt, or the automatic bound) now signals the group: SIGTERM, then a group SIGKILL that also sweeps members which outlived the leader. So no orphaned dev server is left holding a port that the resumed session needs. A body that does not lead its own group (spawned by an older build, inside the daemon's group) has only its own pid signalled. The receipt, the IM reply and the docs now say the background processes end with it. B. Every path that brings a session back drains its FIFO before any new line is submitted: the backlog goes out first, in order, on the next message. If the watcher's rebuild fails after a body ends, the reply target is told why and that the queued messages are kept. D. When a line was queued for a dead child and the resume then failed, the line is withdrawn. The error says the message was NOT delivered (so resending cannot deliver it twice) and names `/new` as the way out. The harness error no longer gives advice of its own. C. The ledger records why a detached body ended: `interrupted`, `auto_recovered` or `exited`. The recovered-answer and unrecoverable notes are worded for the reason recorded. Minors: the kill never runs under the gateway lock. REST /interrupt and /stop await it off the lock. The IM /interrupt door hands it to its own task, which wakes the watcher when done. A failed REST interrupt now returns 502 with the cause instead of 404 "unknown session". Refs docs-local issue #211. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d says so Owner decision: ending a detached body to keep its session (`/interrupt`, or the automatic bound) ends ONLY the body's process. It is one SIGKILL to the fingerprint-checked pid (`session_body::kill_body`). claude answers SIGTERM by killing every background task it started, and those tasks are the user's dev servers, stacks and scheduled jobs. An explicit `/stop` still ends the whole process group: the session is over, and so is its work. The background processes are never signalled, but they are made visible. Before the kill, the body's descendants are walked in /proc and fingerprinted. Each one still running afterwards is recorded as `orphans` (pid + short cmdline) on the `session_body_exited` ledger event, and the reply target is told: "still running, unattended: pid 1234 `npm run dev`". A port the resumed session cannot bind is no longer a mystery. The ledger reasons are `interrupted`, `ended_idle` and `ended_stalled`. The receipt, the IM reply and the docs say the background processes keep running. Refs docs-local issue #211. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t sticky
D1. end_detached_body_shared no longer overwrites an existing end mark.
The watcher decides off the lock, so it could reach a body already
marked by a user's /stop and relabel that end as its own. The watcher
would then rebuild the session the user had just stopped and drain the
queue into it. Only a stop may take over an interrupt. The exit handler
leaves a stop-marked body to the stop. A stop also drops the sid's
rebuild reservation, so a rebuild already spawning off the lock is
discarded rather than installed after the stop.
D2. When the &mut /interrupt door's kill task fails, it flags the sid. The
next reader of the mark lifts `interrupted`, so a later /interrupt
really ends the body and a later natural exit is not recorded as an
interrupt.
Gap 1. When the watcher's rebuild fails, a waiting delegation parent now
gets an honest failure through the normal completion path
(vendor_error, error_kind resume_failed), not just the reply target.
Gap 2. Project delete ends detached bodies off the gateway lock before
stopping their sessions.
Refs docs-local issue #211.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
N1. A detached body's end is now one hand-over entry per sid: in
progress, then ended (with what it left running) or failed. The
watcher, and the exit handler in the same lock as it removes the entry,
defer while a kill has not handed over. So the report never goes out
without the orphans, and a late hand-over can no longer be reported for
a later incarnation. Each orphan carries its start-time fingerprint and
is re-verified when reported, so a reused pid is never named. A stop
drops any stale entry for the sid.
N2. Orphan command lines are redacted before they reach IM or the ledger:
`key=value` and `--key value` for keys naming a token, secret,
password, passwd, key, auth or credential; `Bearer` values; URL
credentials; known token prefixes (sk-, ghp_, gho_, github_pat_,
xox?-, AKIA); and runs of 32+ base64/hex characters. Control
characters and backticks are replaced and the home directory reads as
`~`. For claude's Bash wrapper, the eval payload is shown instead of
the wrapper.
N3. signal_group's doc is back on signal_group. kill_body re-verifies the
body right before the SIGKILL, because the /proc walk widens the
pid-reuse window. Descendants take their start time from the same
/proc/N/stat read. The doc no longer claims that no transcript line is
cut: /interrupt and ended_stalled can tear the last line, which claude
resumes past.
D1 stays as committed in 9e92aa4: an existing mark is never overwritten,
except that a stop may take over an in-flight interrupt.
Docs: /stop ends the session's current process and that process's
background processes; ones left by an earlier /interrupt or automatic end
are not touched.
Refs docs-local issue #211.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…header secrets
C1. A body's kill and its hand-over now run on their own task. Shared
callers (REST /interrupt, /stop, project delete) await the task's
JoinHandle, so a web client that disconnects mid-kill no longer leaves
`InProgress` behind forever, with the sid stuck detached and its queue
undelivered. Each `InProgress` carries a token and a start time: only
the kill that set it writes the outcome, and one older than 30s is
settled as a failed end.
C2. The redactor now also covers:
- quoted values (`--password='x'`, `TOKEN="x"`, `--token "x"`);
- header and JSON members (`X-Api-Key: x`, `{"token":"x"}`) and Basic
credentials;
- `curl -u user:x`, mysql/mariadb `-px` and `sshpass -p x`;
- `pass` and a whole-word `pwd` as sensitive keys;
- URL userinfo with an empty user (`redis://:pw@h`).
Every example from the review is in the test table.
Minors. If a stop finds its body already gone (an /interrupt's SIGKILL got
there first), it still sweeps the body's process group, but only when
a member still carries the sid in its environment. When the watcher's
rebuild fails, the delegated tasks queued for the sid are withdrawn as
their parents are told; a parent's resend is then the only run, never
a late second completion. A human's queued lines stay.
Refs docs-local issue #211.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… path When the watcher cannot bring a session back, the delegated tasks queued for it are withdrawn. Their resume_failed signal used to carry no execution turn, and plan_delegation_delivery answers a boundary nobody's request is bound to with nobody: the parent was never told, and the request stayed outstanding in delegation.json forever. The withdrawn requests are now bound, durably and under the child's store claim, to a turn of their own. That turn is delivered through the ordinary completion path, so the notification has the same identity matching, the same two-phase durability and restart dedup, and the same submission (a live parent is steered, a released one gets the pending FIFO and a resume) as any completion. Each parent hears once that the task was withdrawn and did not run. A REST /stop now runs the whole stop on its own task. A client that disconnects mid-kill no longer leaves a dead body marked `stopping` forever with messages queued behind it. Redaction adds Cookie / Set-Cookie header values, `docker login … -p`, and `echo|printf SECRET | … --password-stdin`. Refs docs-local issue #211. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Re-cut of v0.12.0. The first v0.12.0 release run (37221762018) was cancelled mid-build and its tag removed, so nothing was published (no GitHub release; npm still at 0.11.2). This PR brings the follow-ups that landed since, and the release is tagged after merge.
Since
main(#210):Slack (#19)
slack@<tenant>,/me/im).One @-mention switch for every IM (#20)
require_mention(default off = answer everything). On: in a group or channel the bot answers only messages that @-mention it; DMs, slash commands and buttons are always answered; on Slack, a thread the bot already holds continues without the @.PUT /config/im/{platform}/require-mention, the tenant twin under/me/im, a newGET /me/im; a switch row on all six web cards. Re-saving tokens keeps the switch.Build / install
make installbuilds a thin-LTOlocal-releaseprofile (~6m → ~35s rebuild). Every install-dir ladder (install.sh,ccteam update, status/doctor's install channel, the DSH plugin) treats it as a build tree; embedded plugin tarball re-packed (two-path reproducible).Tests
tests/*.rschecks that drifted outside the CI baseline fixed (F168 anchors all closed;dsh_bintest isolated from the host's npx cache).Local gate: fmt clean · clippy 0 warnings (rustc 1.99.0) ·
make test-baseline2492 passed / 0 failed ·make test3218 passed, 1 failed (body_record_follows_spawn_close_and_detach, full-parallel timing only: 3/3 alone, 38/38 ×2 as a file) · ccteam-web 480 passed + the 3 knownws_*PTY tests · SPA lint + 790 · plugin 289/289.🤖 Generated with Claude Code